#!/bin/python
# run helper of alone c++ file
# by clonne/2021
#
# * now support clang++ only
# * default use c++20
# * it's auto compile if file has update
# * it's auto analysis #include
# * it's auto find pre-compile-header and using
# * always compile if set optimize-level
#=
# before Usage:
#   cp cx.py $PREFIX/bin/cx
#   if no "exe" dir on current dir
#     mkdir exe
# Usage:
#   1 run a.cc or a.cpp
#     cx
#   2 run other file
#     cx b
#   3 run use argument
#     cx b.cc @ a b c
#   4 set optimize level
#     cx .o3 b @ a b c
#   5 test compile options(show command only)
#     cx .tco main
#
# Default Options(for clang++):
#   -std=c++20
#   -Wall -Werror -g
#   -DDEBUG
#   -O1
#   -lreadline
#

import sys,os,subprocess
import re
from pathlib import Path
from functools import *

class const:
    DIR_EXE = Path("./exe/")
    CODE_INCLUDE_PATTERN = re.compile("[^#]*#include[^\"<]*(\"|\<)([^\">]+)")
    TEST_COMPILE_OPTIONS = "//test-compile-options"

default_options = {
    "compiler":"c++",
    "target":"a",
    "sources":["a"],
    "std":"20",
    "optimize":"unset",
    "exflags":["-Wall","-Werror","-g","-ftemplate-backtrace-limit=2"],
    "macros":["DEBUG"],
    "libs":["readline"],
    "source_suffixs":["",".cc",".cpp"],
    "pch_suffixs":[".pch",".gch",".h.pch",".h.gch",".hpp.pch",".hpp.gch"],
    "test-compile-options":False}

def AutoUsePCH(header_paths:[Path],pch_suffixs:[str]):
    def CompileOptions(path_h:Path,path_pch:Path):
        return ["-include",
                path_h.relative_to('.'),
                "-include-pch",
                path_pch.relative_to('.')]
    for path_h in header_paths:
        for path_pch in [path_h.with_suffix(suffix) for suffix in pch_suffixs]:
            if path_pch.is_file():
                return CompileOptions(path_h,path_pch)
    return []

def LinkerArgs(xs:{}):
    def Optimize():
        optimize = xs["optimize"]
        if optimize == "unset":
            optimize = "1"
        if optimize > "0":
            return ["-Xlinker",f"-O{optimize}"]
        return []
    def Libs():
        args = []
        for lib in xs["libs"]:
            args.extend(["-Xlinker",f"-l{lib}"])
        return args
    return [*(Optimize()), *(Libs())]

# Ingnore #include <...>
def GetIncludePath(path_source:Path,include_target:str,symbol:str):
    if symbol == '"':
        for path_dir in [path_source.parent]:
            path_include = Path(path_dir,include_target)
            if path_include.is_file():
                return path_include
    return None
@cache
def Includes(path_source:Path):
    headers = set()
    with path_source.open("r") as f:
        for line in f.readlines():
            r = const.CODE_INCLUDE_PATTERN.match(line)
            if r and len(r.groups()):
                path_header = GetIncludePath(path_source,r.group(2),r.group(1))
                if path_header:
                    headers.add(path_header)
    return headers
def IncludesLevel1(source_paths:[Path]):
    headers = set()
    for path_source in source_paths:
        headers |= Includes(path_source)
    return headers
def IncludesHasUpdate(headers:set,time_target):
    for path_header in headers:
        if path_header.stat().st_mtime > time_target:
            return True
    next_headers = IncludesLevel1(headers)
    if len(next_headers):
        return IncludesHasUpdate(next_headers,time_target)
    return False

# Order of Decide Target Path
# 1 <user-argument>
# 2 <user-argument>.exe
# 3 exe/<user-argument>.exe
# 4 exe/<user-argument>
# use 4 if no matched
def GetTargetPath(xs:{}):
    path_user = xs["target"]
    matchs = [path_user]
    if len(path_user.suffix) < 1:
        matchs.append(path_user.with_suffix(".exe"))
        matchs.append(Path(const.DIR_EXE,matchs[-1]))
    matchs.append(Path(const.DIR_EXE,path_user))
    for p in matchs:
        if p.is_file(): return p
    return matchs[-1]

def Make(xs:{}):
    path_target = GetTargetPath(xs)
    target_mtime = path_target.stat().st_mtime if path_target.is_file() else 0
    source_paths = xs["sources"]
    headers_level1 = IncludesLevel1(source_paths)
    def CompileOptions():
        compiler = xs["compiler"]
        sources = ' '.join(map(str,source_paths))
        std = xs["std"]
        pch = xs.get("pch", AutoUsePCH(headers_level1,xs["pch_suffixs"]))
        exflags = xs["exflags"]
        macros = xs["macros"]
        linker_args = LinkerArgs(xs)
        return [compiler,
                f"-o{path_target}",
                f"-std=c++{std}",
                *([f"-D{m}" for m in macros]),
                *exflags,
                *pch,
                *linker_args,
                sources]
    def Update():
        subprocess.run(CompileOptions(),check=True)
        return path_target
    if xs["test-compile-options"]:
        print(CompileOptions())
        return const.TEST_COMPILE_OPTIONS
    # Trigger Update if set optimize-level
    if xs["optimize"] != "unset":
        return Update()
    # Trigger Update if source has modify
    for path_source in source_paths:
        if path_source.stat().st_mtime > target_mtime:
            return Update()
    # Teigger Update if headers has modify
    if IncludesHasUpdate(headers_level1,target_mtime):
        return Update()
    return path_target

def Call(target:Path,args:[str]):
    if target != const.TEST_COMPILE_OPTIONS:
        try:
            subprocess.run([target.resolve(),*args],check=True)
        except subprocess.CalledProcessError: pass

def BeforeMake(xs:{}):
    def Target(target:str):
        path = Path(xs["sources"][0].stem)
        return path
    def Sources(sources:[]):
        paths = sources.copy()
        suffixs = xs["source_suffixs"]
        def FindBySuffix(i:int):
            for suffix in suffixs:
                p = Path(paths[i]).with_suffix(suffix)
                if p.is_file():
                    paths[i] = p
                    return True
            return False
        def FindByGlob(i:int):
            for g in Path(".").glob(f"{paths[i]}*.cc"):
                paths[i] = g
                return True
            return False
        for i in range(len(paths)):
            if not (FindBySuffix(i) or FindByGlob(i)):
                raise RuntimeError(f"Source \"{paths[i]}\" Not Found.")
        return paths
    def Fix(name:str,f:callable):
        xs[name] = f(xs[name])
    Fix("sources",Sources)
    Fix("target",Target)
    return xs

def DoOptions(args:[]):
    makexs = {**default_options}
    sources = []
    target_args = []
    in_target_args_mode = False
    def Argument(a:str):
        nonlocal in_target_args_mode
        if in_target_args_mode:
            target_args.append(x)
        elif "@" == a:
            in_target_args_mode = True
        elif ".o" == a or ".o2" == a:
            makexs["optimize"] = "2"
        elif ".o3" == a:
            makexs["optimize"] = "3"
        elif ".tco" == a:
            makexs["test-compile-options"] = True
        else:
            sources.append(a)
    if len(args):
        for x in args:
            Argument(x)
        if len(sources):
            makexs["sources"] = sources
    return lambda:Call(Make(BeforeMake(makexs)),target_args)

def Main(args:[]):
    DoOptions(args)()
try:
    Main(sys.argv[1:])
except subprocess.SubprocessError as e:
    print(e)
except RuntimeError as e:
    print("Error:",e)
finally:
    pass

